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 — found through Vite, no presets.
The sandbox runs the model's view in an isolated frame with no network. Out of the box it looks like nothing in your app. genuiSandboxKit() closes the gap with three inputs, and works the same whatever your stack is:
| Option | What it is | What the frame gets | What the model is told |
|---|---|---|---|
styles | Your style entry files: plain CSS, Sass/Less, PostCSS, CSS modules, Bootstrap imported in a .scss, Tailwind v3/v4… | The compiled CSS, fonts and images inlined. With Tailwind, Tailwind's browser runtime with your theme, variants and utilities | Tailwind: your theme tokens and utilities. Other CSS: the classes it defines |
components | Modules or globs exporting React components: shadcn's components/ui, a customized MUI, react-bootstrap, Mantine, your own | Your components and React, as one bundle | Each component and its props, from their types. It writes JSX |
wrapper | A module whose default export wraps the model's App: ({ children }) => <ThemeProvider theme={appTheme}><CssBaseline />{children}</ThemeProvider> | <Wrapper><App /></Wrapper>, in the frame's own document | That the providers are already there |
Your page's CSS custom properties are always passed in too (the theme, on by default), and follow light and dark mode.
import { genuiSandboxKit } from '@adonis-agora/agent/vite'
export default defineConfig({
plugins: [
react(),
adonisjs({ entrypoints: ['resources/css/app.css', 'inertia/app/app.tsx'] }),
genuiSandboxKit({
styles: 'resources/css/app.css',
components: 'inertia/components/ui/*.tsx',
}),
],
})genui: genui({ catalog, sandbox: { kit: true } })kit: true turns on whatever the plugin produced: styles, Tailwind, components, wrapper, docs. Nothing else to configure, and no command to run:
- Dev: the plugin compiles the styles, bundles the components and the wrapper, serves them under
/@genui-sandbox-kit/, and writes the docs. When a component or a stylesheet changes it rebuilds, and every open sandbox reloads with the new kit (HMR). - Build: it emits them as hashed assets plus
genui-sandbox-kit.json(docs and urls), listed in the Vite manifest. - Server:
sandbox({ kit: true })finds them the way@vite()finds your assets (the in-process dev server in development, the manifest in production), andGET /agent/configtells the renderer where they are (genui.sandbox).
The frame stays what it was: sandbox="allow-scripts" without allow-same-origin, and a CSP with no network. The renderer fetches each asset once per page from your own origin and hands it to every frame that needs it (see Weight).
Zero config
genuiSandboxKit() with no options:
- styles: the stylesheets among your Vite entrypoints. That is the AdonisJS plugin's
entrypoints, orbuild.rollupOptions.input, or, without either, the<link rel="stylesheet">s ofindex.html. It also takes the stylesheets those script entries import directly (import './app.css'). CSS modules are left out. - components: the first
components/uifolder it finds (inertia/components/ui,resources/js/components/ui,src/components/ui,app/components/ui,components/ui). - wrapper: none.
What the model is told
Only what exists:
- components and their props, when the kit has components. The model then writes JSX.
- Tailwind, when your styles use it: your theme's colors, radius, fonts, shadows, text sizes and animations, your
@utilitys and your@custom-variants. - CSS classes otherwise: every class your stylesheet defines, grouped into families and written as patterns, most used first (a component's root class ranks above a utility). Families of the same shape share one pattern, so Bootstrap's 2,025 classes, spacing and breakpoints included, take about 10 KB. The model writes
class="btn btn-primary", not its own CSS. - theme variables: the custom properties on your styles' root (
:root,[data-theme],.dark). stylesDocs: your own notes, if you wrote any.
With styles but no components, the model writes HTML with your classes. With components (or a wrapper), it writes JSX.
genuiSandboxKit({
styles: 'resources/css/app.scss',
// A markdown file, or the text itself: when to use what, what never to use.
stylesDocs: 'resources/css/SANDBOX.md',
})Recipes by stack
Complete setups for common stacks. Each one is the plugin's options, the files it points at, and the server line. The server line is the same everywhere:
import { genui } from '@adonis-agora/agent/genui'
import { catalog } from '#genui/catalog'
export default defineConfig({
// …
genui: genui({ catalog, sandbox: { kit: true } }),
})The sizes below are what each frame inlines (see Sizes). The theme's variables are always passed in, whatever the stack.
shadcn/ui + Tailwind v4
The common case, and zero config when your components live in components/ui and your stylesheet is a Vite entrypoint.
import { fileURLToPath } from 'node:url'
import adonisjs from '@adonisjs/vite/client'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { genuiSandboxKit } from '@adonis-agora/agent/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
adonisjs({ entrypoints: ['inertia/css/app.css', 'inertia/app/app.tsx'] }),
// styles: inertia/css/app.css (an entrypoint); components: inertia/components/ui.
genuiSandboxKit(),
],
// The alias your shadcn components import with (`@/lib/utils`): the kit's build uses it too.
resolve: { alias: { '@': fileURLToPath(new URL('./inertia', import.meta.url)) } },
})@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--radius-lg: var(--radius);
/* …the rest of shadcn's tokens */
}
:root { --background: oklch(1 0 0); --primary: oklch(0.52 0.2 268); --radius: 0.625rem; }
.dark { --background: oklch(0.17 0.015 265); --primary: oklch(0.7 0.15 268); }No components entry and no wrapper: every component exported by a file in inertia/components/ui is in the kit, documented from its props. If the defaults don't match your layout, name them: genuiSandboxKit({ styles: 'inertia/css/app.css', components: 'inertia/components/ui' }). Your @tailwindcss/vite is reused for the kit's build.
The model writes JSX with your components (<Card>, <Button variant="outline">) and Tailwind classes from your theme (bg-primary, rounded-lg, text-muted-foreground). Size: React (≈230 KB) plus your components, Tailwind's runtime (≈280 KB), and your compiled CSS.
Tailwind v3 with tailwind.config
Tailwind v3 through PostCSS. The kit maps your config to the v4 runtime (see Tailwind).
genuiSandboxKit({
styles: 'inertia/css/app.css',
components: 'inertia/components/ui',
})@tailwind base;
@tailwind components;
@tailwind utilities;export default {
darkMode: 'class',
content: ['./inertia/**/*.{ts,tsx}'],
theme: {
extend: {
colors: { brand: { DEFAULT: '#7c3aed', 50: '#f5f3ff', 900: '#4c1d95' } },
borderRadius: { lg: '0.75rem' },
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
},
},
}export default { plugins: { tailwindcss: {}, autoprefixer: {} } }The CSS is compiled with your postcss.config.*, as in your build. colors, borderRadius, fontFamily, fontSize, boxShadow, screens, spacing, keyframes and animation (from theme and theme.extend) and darkMode reach the frame's runtime. Function values, plugins and prefix don't. Leave out components for a CSS-only kit.
The model writes JSX with your components, or HTML without them, using your theme's classes (bg-brand, bg-brand-50) and v4 names where v3's differ (shadow-xs, outline-hidden). Size: as v4: Tailwind's runtime (≈280 KB) and your compiled CSS, plus React (≈230 KB) and your components with a kit.
Plain CSS / CSS modules
A global stylesheet becomes a class vocabulary in the prompt:
genuiSandboxKit({
styles: 'inertia/css/app.css',
// Optional: when to use what.
stylesDocs: 'A view is a .panel; .button for actions, .button.is-primary for the main one; .stack for vertical layout.',
}):root { --accent: #2563eb; --radius: 10px; }
.panel { padding: 16px; border-radius: var(--radius); background: var(--surface, #fff); }
.button { padding: 6px 14px; border-radius: var(--radius); }
.button.is-primary { background: var(--accent); color: #fff; }
.stack { display: flex; flex-direction: column; gap: 12px; }CSS modules hash their class names, so they are no vocabulary for the model. Put them behind components instead: the CSS the components import is bundled with the kit.
genuiSandboxKit({
styles: 'inertia/css/app.css', // global styles, if any
components: 'inertia/components/kit', // Button.tsx imports './button.module.css', …
})The model writes HTML with your classes (<div class="panel stack">) when there are only styles, JSX with your components when there is a kit. Size: your compiled CSS (usually small) and inlined fonts. A kit adds React (≈230 KB) and your components.
Sass + Bootstrap 5
CSS only: Bootstrap compiled from Sass with your overrides, and no components.
$primary: #6d28d9;
$border-radius: 0.65rem;
$font-family-sans-serif: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
@import 'bootstrap/scss/bootstrap';
// If your page switches dark mode with `data-theme`, give it Bootstrap's dark rules too
// (the frame mirrors the page's `data-theme` and `data-bs-theme`).
[data-theme='dark'] {
@extend [data-bs-theme='dark'];
}genuiSandboxKit({
styles: 'inertia/css/sandbox.scss',
stylesDocs:
'Wrap the view in a .card with a .card-body; .btn-primary for the main action, .btn-outline-primary for choices; Bootstrap spacing utilities (mb-3, gap-2) for layout.',
})Install sass (npm i -D sass). Your Vite config's css.preprocessorOptions apply. Bootstrap's class list is long: the prompt lists a capped selection, most used first, so stylesDocs is where you say which of them your app actually uses. It takes the text itself, or a markdown file (stylesDocs: 'inertia/css/SANDBOX.md').
The model writes HTML with Bootstrap's classes (<div class="card"><div class="card-body">, <button class="btn btn-primary">). Size: the compiled CSS, ≈230 KB for all of Bootstrap. Import only the partials you use (bootstrap/scss/functions, variables, mixins, root, reboot, buttons, card, forms…) to cut it.
react-bootstrap components + Bootstrap CSS
The same CSS, plus react-bootstrap's components as the kit:
export { Alert, Badge, Button, ButtonGroup, Card, Col, Form, InputGroup, ListGroup, Row, Stack, Table } from 'react-bootstrap'genuiSandboxKit({
styles: 'inertia/css/sandbox.scss', // or the stock CSS: 'bootstrap/dist/css/bootstrap.min.css'
components: 'inertia/sandbox/components.ts',
})A re-export module keeps the kit to what a view needs. components: 'react-bootstrap' works too, but bundles and documents the whole library.
The model writes JSX with react-bootstrap's components (<Card><Card.Body>, <Button variant="primary">, <Form.Range>) and Bootstrap's utility classes. Size: the Bootstrap CSS (≈230 KB in full), React (≈230 KB) and the react-bootstrap components you export.
MUI with a custom theme
A theme and a provider, no stylesheet. The wrapper gives the model's App your ThemeProvider and CssBaseline:
import { createTheme } from '@mui/material/styles'
export function appTheme(mode: 'light' | 'dark') {
return createTheme({
palette: { mode, primary: { main: mode === 'dark' ? '#4db6ac' : '#00796b' } },
shape: { borderRadius: 14 },
typography: { button: { textTransform: 'none', fontWeight: 700 } },
})
}import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider } from '@mui/material/styles'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { appTheme } from './theme'
/** Light or dark, as the frame's root says (it mirrors the page's `data-theme`). */
function useMode(): 'light' | 'dark' {
const read = () => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
const [mode, setMode] = useState<'light' | 'dark'>(read)
useEffect(() => {
const observer = new MutationObserver(() => setMode(read()))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
return mode
}
export default function SandboxWrapper({ children }: { children?: ReactNode }) {
const mode = useMode()
const theme = useMemo(() => appTheme(mode), [mode])
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}import { styled } from '@mui/material/styles'
export {
Alert, Box, Button, Card, CardActions, CardContent, CardHeader, Chip, Divider,
Menu, MenuItem, Slider, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography,
} from '@mui/material'
/** Your own styled() components join the kit like any other. */
export const PriceTag = styled('span')(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: '1.75rem',
fontWeight: 700,
}))genuiSandboxKit({
components: 'inertia/sandbox/components.tsx',
wrapper: 'inertia/sandbox/wrapper.tsx',
stylesDocs: 'A Card with a CardHeader; Stack for layout; the main action is a contained Button. Use sx for one-off spacing, never CSS.',
})Emotion inserts its styles into the frame's own document, so the theme, CssBaseline and every styled() component apply there. Portals (Menu, Dialog, Popover, Tooltip) mount into the frame's <body> and open inside the frame. Without the useMode hook, pass a fixed theme.
The model writes JSX with MUI's components and your own (<Card><CardHeader title="…" />, <Slider>, sx={{ mt: 2 }}). Size: React (≈230 KB) plus ≈180 KB for a dozen MUI components with emotion and a theme. No stylesheet.
styled-components / emotion without MUI
Your styled components as the kit, your theme in a wrapper:
import type { ReactNode } from 'react'
import { ThemeProvider } from 'styled-components' // or: import { ThemeProvider } from '@emotion/react'
import { theme } from '../theme'
export default function SandboxWrapper({ children }: { children?: ReactNode }) {
return <ThemeProvider theme={theme}>{children}</ThemeProvider>
}export { Button } from '../components/button'
export { Card, CardTitle } from '../components/card'
export { Stack } from '../components/stack'genuiSandboxKit({
components: 'inertia/sandbox/components.ts',
wrapper: 'inertia/sandbox/wrapper.tsx',
})Global styles (createGlobalStyle, emotion's <Global>) go in the wrapper too.
The model writes JSX with your components and their props (<Card><CardTitle>…</CardTitle><Button $variant="primary">). Size: React (≈230 KB), the CSS-in-JS runtime, and your components.
Mantine or Chakra
Their provider in the wrapper. Mantine also ships a stylesheet, which goes in styles:
import { createTheme, MantineProvider } from '@mantine/core'
import type { ReactNode } from 'react'
const theme = createTheme({ primaryColor: 'violet', defaultRadius: 'md' })
export default function SandboxWrapper({ children }: { children?: ReactNode }) {
return <MantineProvider theme={theme}>{children}</MantineProvider>
}export { Badge, Button, Card, Group, NumberInput, Slider, Stack, Text, TextInput, Title } from '@mantine/core'genuiSandboxKit({
styles: '@mantine/core/styles.css',
components: 'inertia/sandbox/components.ts',
wrapper: 'inertia/sandbox/wrapper.tsx',
})Chakra is CSS-in-JS (emotion), so it needs no styles: the wrapper is <ChakraProvider value={system}>{children}</ChakraProvider> (v3, with your createSystem(defaultConfig, …)), and components re-exports the Chakra components a view needs.
The model writes JSX with the library's components (<Card withBorder>, <Group>, <Slider>). Size: React (≈230 KB) and the components you export. Mantine adds its stylesheet (every component's CSS; list its per-component files in styles instead to cut it). Chakra adds emotion and its theme.
Your own component library from a workspace package
Components from a package name, styles from the package's CSS:
genuiSandboxKit({
components: '@acme/ui', // the package's exports are the kit
styles: '@acme/ui/styles.css', // a stylesheet the package exports
// wrapper: '@acme/ui/sandbox-wrapper', // if the library needs a provider
})The kit resolves the package from your app, like any import. Its props are documented from the package's types, so it needs its .d.ts (or its TypeScript sources) to resolve. The kit's build uses your app's single copy of React. A workspace package, linked or injected by pnpm (dependenciesMeta: { '@acme/ui': { injected: true } }), is followed to its real files: its stylesheets' @imports resolve from the package, and in dev an edit to it rebuilds the kit. A package export lists everything, so for a large library a re-export module (components: 'inertia/sandbox/components.ts') keeps the frame small (the docs list at most 120 components).
The model writes JSX with your library's components, or HTML with its classes if you pass only styles. Size: React (≈230 KB) plus everything the package exports and its CSS, unless you re-export a subset.
A separate SPA (no Vite in the server process)
The SPA's own Vite config runs the plugin. It serves and builds the kit's assets, and can write the dev descriptor where the server reads it:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { genuiSandboxKit } from '@adonis-agora/agent/vite'
export default defineConfig(({ command }) => ({
plugins: [
react(),
genuiSandboxKit({
styles: 'src/index.css',
components: 'src/components/ui',
// Dev: write the docs and asset urls where the Adonis server looks for them.
descriptor: '../api/.adonisjs/agent/genui_sandbox_kit.json',
}),
],
// Build where the server looks (`config/vite.ts` defaults: public/assets, served at /assets/).
base: command === 'build' ? '/assets/' : '/',
build: { outDir: '../api/public/assets', assetsDir: '', emptyOutDir: true, manifest: true },
// Dev: the page, the frame's assets and the agent on one origin.
server: { proxy: { '/agent': 'http://localhost:3333' } },
}))genui: genui({ catalog, sandbox: { kit: true } })Serving the assets. The asset urls are same-origin paths: /@genui-sandbox-kit/kit.js?v=… from the SPA's dev server, /assets/genui-sandbox-kit-….js from the build. In dev, open the app through the SPA's dev server (it proxies /agent). In production, Adonis serves public/ (@adonisjs/static) and finds the kit in public/assets/.vite/manifest.json (set buildDirectory and manifestFile in config/vite.ts if yours differ). If you can't point the descriptor at the server (the SPA lives in another repo), the server still needs the docs: the assembler hook writes them on serve and build, see Without Vite in-process.
import { genuiKitHook } from '@adonis-agora/agent/hooks/genui_kit'
hooks: {
init: [genuiKitHook({ components: '../web/src/components/ui', styles: '../web/src/index.css' })],
}The hook's descriptor has docs and no asset urls, and it is the same file the plugin's descriptor above writes. Use one or the other. With the hook, tell the server where the SPA serves the kit (genui({ sandboxKit: { kitUrl, cssUrl, tailwindUrl } }), see Without Vite in-process), unless its build lands in the server's public/assets, where the manifest is found.
The model writes whatever the kit holds, as in the recipes above. Size: the same. Nothing changes in the frame.
Tailwind
Tailwind is detected, never configured. A style entry (or a file it @imports) that has @import "tailwindcss", @theme, @utility, @custom-variant or @tailwind, or a compiled stylesheet with Tailwind's banner, turns it on.
The model writes classes your build never generated (it is writing a new view). So the frame runs Tailwind's browser runtime (@tailwindcss/browser, v4), which generates CSS for whatever classes are on screen, offline. It gets:
- your stylesheet's directives:
@themeblocks,@custom-variants (shadcn'sdata-open:, adarkvariant ondata-theme),@utilitys. That includes those of the files it imports, such asshadcn/tailwind.cssortw-animate-css. Imports,@sources,@plugins and base styles are not passed to the runtime: they come compiled; - an
@theme inlinemapped from your page's variables (colors become--color-*, bare shadcn v3H S% L%channels are wrapped inhsl(),--radiusgivesrounded-sm…rounded-xl), and adarkvariant matching both.darkand[data-theme=dark].
The compiled CSS is inlined too, for your base styles, your @layer components, and fonts. Your app's @tailwindcss/vite plugin is added to the kit's build automatically. A PostCSS setup (@tailwindcss/postcss, or v3's tailwindcss) is picked up from postcss.config.* like in your build.
genuiSandboxKit({ styles: 'resources/css/app.css', components: 'inertia/components/ui' })@import "tailwindcss";
@import "shadcn/tailwind.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme inline { --color-primary: var(--primary); --radius-lg: var(--radius); }
:root { --primary: oklch(0.52 0.2 268); --radius: 0.625rem; }Tailwind v3. The runtime is v4. The kit maps your tailwind.config.* (or the file an @config names) to a v4 @theme, from theme and theme.extend:
colorsbecome--color-*, with nested shades flattened,DEFAULTdropped, and<alpha-value>replaced by1;borderRadius,fontFamily,fontSize(with its line height),boxShadow,screens,spacing,keyframesandanimationmap the same way;darkMode: 'class' | 'selector' | ['class', '<selector>']becomes adarkvariant.
Function values, plugins and prefix are not mapped. The model is told to write v4 class names (shadow-xs where v3 had shadow-sm, outline-hidden for outline-none).
Size
The runtime is ≈280 KB (≈75 KB gzipped), fetched once per page and handed to every frame. The compiled CSS ships without the utilities your kit does not use, because the runtime generates every utility the model writes (see Purging).
Plain CSS / Sass / Bootstrap
Any stylesheet Vite can compile works: plain CSS, Sass, Less, Stylus, PostCSS, CSS modules. It is compiled with Vite's own CSS pipeline and your Vite config's css options (preprocessorOptions, modules, postcss, transformer), so a Sass additionalData, an alias in an @import, or a PostCSS plugin behave as in your app. Install the preprocessor your app already uses (sass, less).
$primary: #7c3aed;
$border-radius: 0.75rem;
@import "bootstrap/scss/bootstrap";genuiSandboxKit({ styles: 'resources/css/sandbox.scss' })The model is told the vocabulary and writes HTML with it. Every class is covered, written as patterns: a{b,c} means ab and ac, and {0..5} means 0 to 5. Families of the same shape (m-*, mt-*, mb-*…) share one line:
- btn: btn btn-{group,check,close,link,lg,sm,toolbar,danger,dark,info,light,primary,secondary,success,warning} btn-outline-{danger,…,warning}
- card: card card-{group,img,footer,header,body,subtitle,title,link,text} card-img-{bottom,top,overlay}
- active show fade h1 h2 lead small …
- m{,b,e,s,t,x,y}-{0..5,auto} m{,b,e,s,t,x,y}-{lg,md,sm,xl,xxl}-{0..5,auto}
- {g,gap,gx,gy,p,pb,ps,pt,px,py}-{0..5} {g,gap,gx,gy,p,pb,ps,pt,px,py}-{lg,md,sm,xl,xxl}-{0..5}
…
Its custom properties, for your own css: var(--bs-primary), var(--bs-body-font-family), …extractCssVocabulary(css, { maxChars }) caps the text (default 12,000 characters); the rarest families go first, and the model is told how many classes were left out.
A package stylesheet works directly, too: styles: 'bootstrap/dist/css/bootstrap.min.css'.
Fonts and images referenced by url() are inlined as data: URIs. Each file can be up to inlineAssetLimit (default 128 KB), and all of them together up to inlineAssetBudget (default 1 MB). Anything over those, or anything remote, is left out with a warning in the Vite log, and the browser falls back to the next font in the stack or no background image. Absolute urls (/fonts/x.woff2) are read from your publicDir.
Light and dark. The frame mirrors your page root's dark class and every data-*theme, data-*mode or data-*scheme attribute (data-theme, data-bs-theme, data-color-scheme…), live. Bootstrap's data-bs-theme="dark" therefore follows your page.
MUI / emotion / styled-components
A CSS-in-JS design system has no stylesheet. It is a theme and a provider. Give the kit the components and the provider:
import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider } from '@mui/material/styles'
import { appTheme } from '../theme'
export default function SandboxWrapper({ children }: { children?: React.ReactNode }) {
return (
<ThemeProvider theme={appTheme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}export { Button, Card, CardContent, Slider, Stack, TextField, Typography, Menu, MenuItem } from '@mui/material'
export { PriceTag } from '../components/price_tag' // your own styled() componentsgenuiSandboxKit({
components: 'resources/js/sandbox/components.ts',
wrapper: 'resources/js/sandbox/wrapper.tsx',
})The wrapper is bundled with the components, and the frame renders <Wrapper><App /></Wrapper>. Emotion and styled-components insert their styles into the document they run in, which is the frame's own, so the theme, CssBaseline's baseline and every styled() component apply there. Portals (MUI's Menu, Dialog, Popover, Tooltip) mount into the frame's <body> and open inside the frame.
components also takes a package (components: '@mui/material'), but that bundles and documents the whole library. A module re-exporting what a generated view needs keeps the frame small and the prompt short (the docs list at most 120 components).
A JavaScript project (no TypeScript) gets each component's name, plus its propTypes keys and defaultProps, when it has them.
TypeScript 7
The docs need TypeScript's JavaScript compiler API (5.x/6.x). With only TypeScript 7 (the native compiler) installed, add typescript5@npm:typescript@5 and pass genuiSandboxKit({ typescript: await import('typescript5') }). Otherwise the docs fall back to names and propTypes.
A frame is only as tall as its content. A dialog taller than the view is clipped to the frame. Prefer inline disclosure (an accordion, a collapse) to modals in generated views.
What the model writes
With components (or a wrapper), the sandbox takes a jsx field: plain JavaScript + JSX that defines function App(). Kit components and React's hooks are in scope by name:
function App() {
const [people, setPeople] = useState(3)
const [total, setTotal] = useState(120)
const each = people > 0 ? total / people : 0
return (
<Card className="p-4">
<CardHeader><CardTitle>Split the bill</CardTitle></CardHeader>
<CardContent className="space-y-3">
<Input type="number" value={total} onChange={(e) => setTotal(Number(e.target.value) || 0)} />
<Slider min={1} max={12} value={[people]} onValueChange={(v) => setPeople(v[0])} />
<p className="text-muted-foreground">Each pays {each.toFixed(2)}</p>
<Button onClick={() => agent.send({ text: `Split ${total} between ${people}`, total, people })}>Settle</Button>
</CardContent>
</Card>
)
}It is compiled inside the frame by a small JSX transpiler inlined into it (no network, no unsafe-eval), and rendered into the frame's root. While the model writes it, the view goes from skeleton to preview to live view:
- a placeholder of
initialHeight; - a preview, every time what has arrived closes into code that parses and renders (a half-written attribute is held back, an open element is closed);
- the live view, once the call is complete.
A version that does not parse or throws never replaces the last good one, so nothing is ever shown half broken. agent.send only counts from the live view.
With styles only, it writes html (and jsFunctions/jsExpressions), using your classes.
Without Vite in-process
An app whose frontend is a separate SPA builds the kit with the same plugin in its own Vite config (a complete setup: A separate SPA). The server still needs the docs. The plugin's descriptor option can write them where the server reads them. Otherwise, the assembler hook that node ace configure @adonis-agora/agent adds writes .adonisjs/agent/genui_sandbox_kit.json on serve and build, and again when a kit or style file changes:
import { genuiKitHook } from '@adonis-agora/agent/hooks/genui_kit'
hooks: {
// Same options as the plugin: styles, components, wrapper, stylesDocs.
init: [genuiKitHook({ components: 'web/src/components/ui', styles: 'web/src/app.css' })],
}The hook compiles the styles with Vite when it is installed (for Sass and PostCSS); without it, it reads plain CSS as it is.
The descriptor the hook writes has the docs but no asset urls: the SPA's dev server or build serves the kit's files, and this process cannot see where (a build in the server's public/assets is found through its manifest). Tell the server where they are, and GET /agent/config passes them on to the renderer:
genui: genui({
catalog,
sandbox: { kit: true },
// Where the SPA serves what its genuiSandboxKit() built: its manifest lists them under
// genui-sandbox-kit.js, genui-sandbox-kit.css and genui-sandbox-tailwind.js.
sandboxKit: {
kitUrl: '/spa/assets/genui-sandbox-kit.js',
cssUrl: '/spa/assets/genui-sandbox-kit.css',
tailwindUrl: '/spa/assets/genui-sandbox-tailwind.js',
},
})Each url must be fetchable by the page: same origin, or served with CORS. Give only the ones your kit has. NestJS takes the same three options in AgentGenuiModule.forRoot({ sandboxKit }).
Weight
Every frame needs the kit's styles, the Tailwind runtime and the kit bundle. Each frame is its own document: an opaque origin with its own JavaScript realm, so it parses and runs them itself. React from one frame cannot draw in another. What the page can avoid is copying them into every srcdoc.
Shared assets (the default)
The renderer fetches each asset once per page. A frame's srcdoc carries only the bridge, the theme, the model's CSS and markup, and placeholders for the assets it needs. Once it has loaded, the frame asks its host for them (agora:sandbox:load). The host answers with their text (agora:sandbox:assets), and the frame puts them in inline <style> and <script> elements, in the order an inlined document has them. Its body stays hidden until then, so nothing draws unstyled. It then says ready, and the host posts the JSX as before.
createSandboxRenderer({ assets: 'inline' }) // every asset in every srcdoc, as before 0.78Isolation is unchanged. The frame is still sandbox="allow-scripts" without allow-same-origin, and its CSP is exactly the inlined document's: inline code only, no network, no blob:. The assets arrive by postMessage, which the frame accepts only from its parent window and with its own token, the same check as the other host messages. They are the same public files of your origin that the inlined document carried. A frame cannot ask for anything else: it gets the assets the page already has, or nothing.
Two other approaches were measured and rejected. Posting each asset as a Blob that the frame loads from a blob: URL of its own copies nothing, but it needs blob: in script-src and style-src. It was also about 300 ms slower to first paint, because blob URLs load asynchronously. A hidden "runtime host" frame cannot share its React with other frames: each opaque origin has its own realm, and drawing into another frame's DOM would need allow-same-origin.
Measured on a page with 5 kit sandboxes (Lastro's kit: 461 KB of CSS, a 282 KB Tailwind runtime and a 279 KB React kit), in headless Chromium 1248:
| Inlined (0.77) | Shared | Shared + purged CSS | |
|---|---|---|---|
HTML parsed (5 srcdocs) | 5.36 MB | 0.25 MB | 0.25 MB |
| CSS + JS parsed by the frames | (in the HTML) | 5.11 MB | 4.07 MB |
| Browser memory (PSS of every Chromium process) | 415 MB | 405 MB | 378 MB |
| All 5 frames drawn (median) | 713 ms | 930 ms | 732 ms |
Medians of 11 runs per column on a busy shared host, so the timings are noisy: their spread (450–1,400 ms) is wider than the differences between columns. What sharing measurably removes is the HTML parsing and the copies of each srcdoc. The purge removes about a fifth of the bytes the frames parse, and with them some memory. The CSS and JavaScript are still parsed once per frame: that is the price of isolating each frame.
React Native keeps inlining. Its bridge carries strings only, and each WebView is its own process anyway.
Purging
The plugin compiles your app's whole stylesheet. purge, on by default, takes out what the frame can never use:
- Tailwind v4: the
utilitieslayer keeps only the utilities your kit's own code names. The frame's Tailwind runtime generates every other utility the model writes, from your theme. Thetheme,baseandcomponentslayers,@font-face,@keyframesand@propertystay whole. - Tailwind v3 (its output has no layers): every rule naming a class the kit does not use goes.
- Other CSS (Bootstrap, Sass, plain CSS): only generated CSS-modules classes the kit does not use go. Every other class is in the vocabulary the model is told, so it stays.
"The kit's own code" is the kit bundle: every class name its components can put on an element, including CSS-modules hashes and cn()/cva() strings.
genuiSandboxKit({ purge: false }) // ship the whole stylesheet
genuiSandboxKit({ purge: { safelist: ['prose', /^prose-/] } }) // keep a plugin's classes the model is told aboutA Tailwind @plugin's utilities (prose) are not generated by the browser runtime. Safelist the ones you tell the model about in stylesDocs.
The dev server logs the result: [genui-sandbox-kit] styles purged: 453 KB → 249 KB. Sizes for the example app's kits:
| Kit | Compiled | Purged |
|---|---|---|
| shadcn (Tailwind v4 + components) | 14.2 KB | 12.7 KB |
| Bootstrap 5 (Sass, CSS only) | 225.2 KB | 225.2 KB: every class is in the vocabulary |
| MUI (emotion, no stylesheet) | 0 | 0 |
| Lastro (Tailwind v4, Geist fonts inlined) | 464.4 KB | 255.4 KB: 199 KB of it is @font-face |
Several kits in one app
Give each genuiSandboxKit() a name, for variants of your design system, a tenant's own, or an A/B test. Each named kit is built on its own and served under /@genui-sandbox-kit/<name>/. It writes its own descriptor (genui_sandbox_kit.<name>.json) and is listed under its own manifest keys (genui-sandbox-kit.<name>.js…). A named kit's rebuilds reload only the sandboxes drawn with it.
plugins: [
genuiSandboxKit({ name: 'brand', styles: 'resources/css/brand.css', components: 'inertia/brand/ui' }),
genuiSandboxKit({ name: 'partner', styles: 'resources/css/partner.scss' }),
]genui: genui({ catalog, sandbox: { kit: true, kitName: 'brand' } })To pick a kit per request, resolve a catalog per actor (resolver), each with a sandbox for one kit. Find each kit with adonisSandboxKitDiscovery(app, { name }) and resolveSandboxServer() from @adonis-agora/agent/genui/kit, and give the renderer that kit's client config (createSandboxRenderer({ config })). The example app's kit switcher does this per visitor.
Reference
| Export | From | |
|---|---|---|
genuiSandboxKit(options) | @adonis-agora/agent/vite | The Vite plugin: name, styles, stylesDocs, components, wrapper, purge, plugins, tsconfig, typescript, inlineAssetLimit, inlineAssetBudget, descriptor |
genuiKitHook(options) | @adonis-agora/agent/hooks/genui_kit | The assembler hook: name, styles, stylesDocs, components, wrapper, tsconfig, typescript, output |
genui({ sandboxKit: { kitUrl, cssUrl, tailwindUrl } }) | @adonis-agora/agent/genui | Asset urls for a hook-only setup (an SPA serves the kit) |
defineSandbox({ theme, kit, kitName }) | @adonis-agora/agent/genui | kit also takes docs (or a function returning them); kitName picks a named kit |
createSandboxRenderer({ config, theme, assets }) | @adonis-agora/agent/react/genui | config: an object, a url, or false; default the agent's /config. assets: 'shared' (default) or 'inline' |
writeSandboxKitDocs, buildSandboxKitBundle, buildSandboxKitStyles, purgeSandboxKitStyles, discoverStyleEntries, sandboxKitDiscovery, adonisSandboxKitDiscovery, resolveSandboxServer | @adonis-agora/agent/genui/kit | The Node half, for your own tooling |
extractCssVocabulary, purgeCss, tailwindTokensFromCss, tailwindV3ConfigToCss, inlineCssUrls, transpileJsx, tailwindThemeCss, collectHostThemeVars | @adonis-agora/agent/genui | The isomorphic pieces |
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.
Sandbox in React Native
The sandbox component drawn in a hardened react-native-webview: the same document and CSP as the web iframe, the same checked agent.send bridge, sized to its content, themed with the app's tokens.