Aviary
Guides

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.

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:

OptionWhat it isWhat the frame getsWhat the model is told
stylesYour 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 utilitiesTailwind: your theme tokens and utilities. Other CSS: the classes it defines
componentsModules or globs exporting React components: shadcn's components/ui, a customized MUI, react-bootstrap, Mantine, your ownYour components and React, as one bundleEach component and its props, from their types. It writes JSX
wrapperA module whose default export wraps the model's App: ({ children }) => <ThemeProvider theme={appTheme}><CssBaseline />{children}</ThemeProvider><Wrapper><App /></Wrapper>, in the frame's own documentThat 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.

Add the plugin to the Vite config of the frontend that talks to the agent:

Frontend — vite.config.ts
import react from '@vitejs/plugin-react';
import { genuiSandboxKit } from '@dudousxd/nestjs-agent/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    react(),
    genuiSandboxKit({ styles: 'src/index.css', components: 'src/components/ui' }),
  ],
});
Backend — src/app.module.ts
AgentGenuiModule.forRoot({ catalog, sandbox: { kit: true } });

Or define it in the shared catalog. AgentGenuiModule resolves a catalog sandbox with kit the same way, under its own name:

Shared — src/genui/catalog.ts
export const catalog = defineCatalog([...BUILTIN_COMPONENTS, defineSandbox({ 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, and serves them under /@genui-sandbox-kit/. It writes the docs and urls to .genui/sandbox-kit.json (relative to the Vite root; add .genui/ to .gitignore). 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, listed in the Vite manifest under genui-sandbox-kit.js, genui-sandbox-kit.css, genui-sandbox-tailwind.js and genui-sandbox-kit.json.
  • Server: no Vite runs in the Nest process, so AgentGenuiModule reads files. In production (NODE_ENV=production) it reads the Vite manifest first. Otherwise it reads whichever of the dev descriptor and the build's descriptor was written last, so a stale one never shadows a newer one. GET /agent/config then tells the renderer where the assets are (genui.sandbox), and the model is told what exists.

The asset urls are same-origin paths (/@genui-sandbox-kit/kit.js?v=… in dev, /assets/genui-sandbox-kit-….js in a build). Serve the SPA and the agent from one origin (the Vite dev server proxying /agent to Nest, ServeStaticModule serving the build), or set sandboxKit.kitUrl.

Where the server looks: sandboxKit

Backend — src/app.module.ts
AgentGenuiModule.forRoot({
  catalog,
  sandbox: { kit: true },
  sandboxKit: {
    root: join(__dirname, '..'),                  // default process.cwd(); the paths below resolve against it
    descriptor: 'web/.genui/sandbox-kit.json',    // default .genui/sandbox-kit.json
    manifest: { file: 'public/.vite/manifest.json', outDir: 'public' }, // the default; `false` for none
    // kitUrl: 'https://app.example.com/assets/kit.js', // an SPA served from elsewhere
    // production: true,                          // default NODE_ENV === 'production'
  },
});

The default manifest is public/.vite/manifest.json, with its files relative to public. That is what a Vite build with build: { outDir: 'public', manifest: true } writes. If your build writes elsewhere, point manifest at its real output (client/dist/.vite/manifest.json with outDir: 'client/dist', …). What was asked for but not found is left out of what the browser and the model are told, so a missing build degrades to the plain sandbox.

Security

None of this loosens the security model. The frame stays what it was: sandbox="allow-scripts" without allow-same-origin, and a CSP with no network. The styles, Tailwind and the kit are inlined into the frame's document. The renderer fetches each once from your own origin, caches it, and puts it in the srcdoc, so the frame itself still loads nothing. The JSX is compiled inside the frame by a small transpiler inlined with it (shipped prebuilt as a string, so a minifying build of your app cannot break it). No 'unsafe-eval' is needed, and none is granted.

What you put in the kit is code the generated view can call. Keep it to presentational components. A component that calls your API (with the user's session, from the host page) does not belong in a kit: inside the frame it has no cookies and no network, so it would only fail.

Zero config

genuiSandboxKit() with no options:

  • styles: the stylesheets among your Vite entrypoints. That is build.rollupOptions.input, or, without it, the <link rel="stylesheet">s of index.html. It also takes the stylesheets those script entries import directly (import './app.css'). CSS modules are left out.
  • components: the first components/ui folder it finds (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: the classes your stylesheet defines, grouped into families and deduplicated, most used first (a component's root class ranks above a utility). The list is capped. 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: 'src/styles/app.scss',
  // A markdown file, or the text itself: when to use what, what never to use.
  stylesDocs: 'src/styles/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:

Backend — src/app.module.ts
import { AgentGenuiModule } from '@dudousxd/nestjs-agent/genui';
import { catalog } from './genui/catalog';

@Module({
  imports: [
    // …
    AgentGenuiModule.forRoot({ catalog, sandbox: { kit: true } }),
  ],
})
export class AppModule {}

Where the server finds what the plugin built is sandboxKit: by default .genui/sandbox-kit.json in dev and public/.vite/manifest.json in production, relative to the working directory.

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 imported by your entry (or linked from index.html).

Frontend — vite.config.ts
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { genuiSandboxKit } from '@dudousxd/nestjs-agent/vite';

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    // styles: src/index.css (imported by your entry, src/main.tsx); components: src/components/ui.
    genuiSandboxKit(),
  ],
  // The alias your shadcn components import with (`@/lib/utils`): the kit's build uses it too.
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
});
src/index.css
@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 src/components/ui is in the kit, documented from its props. If the defaults don't match your layout, name them: genuiSandboxKit({ styles: 'src/index.css', components: 'src/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).

vite.config.ts
genuiSandboxKit({
  styles: 'src/index.css',
  components: 'src/components/ui',
})
src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
tailwind.config.js
export default {
  darkMode: 'class',
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: { brand: { DEFAULT: '#7c3aed', 50: '#f5f3ff', 900: '#4c1d95' } },
      borderRadius: { lg: '0.75rem' },
      fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
    },
  },
}
postcss.config.js
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:

vite.config.ts
genuiSandboxKit({
  styles: 'src/index.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.',
})
src/index.css
: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.

vite.config.ts
genuiSandboxKit({
  styles: 'src/index.css',       // global styles, if any
  components: 'src/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.

src/styles/sandbox.scss
$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'];
}
vite.config.ts
genuiSandboxKit({
  styles: 'src/styles/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: 'src/styles/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:

src/sandbox/components.ts
export { Alert, Badge, Button, ButtonGroup, Card, Col, Form, InputGroup, ListGroup, Row, Stack, Table } from 'react-bootstrap'
vite.config.ts
genuiSandboxKit({
  styles: 'src/styles/sandbox.scss', // or the stock CSS: 'bootstrap/dist/css/bootstrap.min.css'
  components: 'src/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:

src/sandbox/theme.ts
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 } },
  })
}
src/sandbox/wrapper.tsx
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>
  )
}
src/sandbox/components.tsx
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,
}))
vite.config.ts
genuiSandboxKit({
  components: 'src/sandbox/components.tsx',
  wrapper: 'src/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:

src/sandbox/wrapper.tsx
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>
}
src/sandbox/components.ts
export { Button } from '../components/button'
export { Card, CardTitle } from '../components/card'
export { Stack } from '../components/stack'
vite.config.ts
genuiSandboxKit({
  components: 'src/sandbox/components.ts',
  wrapper: 'src/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:

src/sandbox/wrapper.tsx
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>
}
src/sandbox/components.ts
export { Badge, Button, Card, Group, NumberInput, Slider, Stack, Text, TextInput, Title } from '@mantine/core'
vite.config.ts
genuiSandboxKit({
  styles: '@mantine/core/styles.css',
  components: 'src/sandbox/components.ts',
  wrapper: 'src/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:

vite.config.ts
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: 'src/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)

No Vite runs in the Nest process anyway: the frontend's Vite config runs the plugin, and the server reads files. When the SPA is its own project (web/ beside the Nest app), point sandboxKit at what its plugin writes:

web/vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { genuiSandboxKit } from '@dudousxd/nestjs-agent/vite';

export default defineConfig({
  plugins: [react(), genuiSandboxKit({ styles: 'src/index.css', components: 'src/components/ui' })],
  build: { outDir: 'dist', manifest: true },
  // Dev: the page, the frame's assets and the agent on one origin.
  server: { proxy: { '/agent': 'http://localhost:3000' } },
});
Backend — src/app.module.ts
import { join } from 'node:path';
import { ServeStaticModule } from '@nestjs/serve-static';

@Module({
  imports: [
    AgentGenuiModule.forRoot({
      catalog,
      sandbox: { kit: true },
      sandboxKit: {
        root: join(__dirname, '..'),
        descriptor: 'web/.genui/sandbox-kit.json', // what the SPA's dev server writes
        manifest: { file: 'web/dist/.vite/manifest.json', outDir: 'web/dist' }, // what its build writes
      },
    }),
    // Production: the SPA's build (the kit's assets included) from the same origin.
    ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'web', 'dist') }),
  ],
})
export class AppModule {}

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, serve the build from the Nest app (ServeStaticModule), or set sandboxKit.kitUrl when the SPA is served from elsewhere.

When the frontend is not built with this plugin, or lives in another repo, the server still needs the docs. The @dudousxd/nestjs-codegen extension writes them on every codegen pass, see Without Vite in-process:

nestjs-codegen.config.ts
import { defineConfig, genuiSandboxKit } from '@dudousxd/nestjs-codegen';

export default defineConfig({
  contracts: { glob: 'src/**/*.controller.ts' },
  codegen: { outDir: 'src/generated' },
  extensions: [genuiSandboxKit({ components: 'web/src/components/ui', styles: 'web/src/index.css' })],
});

Its descriptor (.genui/sandbox-kit.json, the module's default) has docs and no asset urls. Point sandboxKit.descriptor at one file or the other.

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: @theme blocks, @custom-variants (shadcn's data-open:, a dark variant on data-theme), @utilitys. That includes those of the files it imports, such as shadcn/tailwind.css or tw-animate-css. Imports, @sources, @plugins and base styles are not passed to the runtime: they come compiled;
  • an @theme inline mapped from your page's variables (colors become --color-*, bare shadcn v3 H S% L% channels are wrapped in hsl(), --radius gives rounded-sm…rounded-xl), and a dark variant matching both .dark and [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.

vite.config.ts
genuiSandboxKit({ styles: 'src/index.css', components: 'src/components/ui' })
src/index.css
@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:

  • colors become --color-*, with nested shades flattened, DEFAULT dropped, and <alpha-value> replaced by 1;
  • borderRadius, fontFamily, fontSize (with its line height), boxShadow, screens, spacing, keyframes and animation map the same way;
  • darkMode: 'class' | 'selector' | ['class', '<selector>'] becomes a dark variant.

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 it is inlined into every frame. The compiled CSS is whatever your build produces: with @sources scanning a whole monorepo, that can be large. Point styles at a stylesheet that sources less if it matters.

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).

src/styles/sandbox.scss
$primary: #7c3aed;
$border-radius: 0.75rem;
@import "bootstrap/scss/bootstrap";
vite.config.ts
genuiSandboxKit({ styles: 'src/styles/sandbox.scss' })

The model is told the vocabulary (a capped list of class families, most used first) and writes HTML with it:

- btn: btn btn-{primary,secondary,success,danger,lg,sm,…} btn-outline-{primary,…} btn-group
- card: card card-{body,header,footer,title,text}
- form: form-{control,select,label,check,range} form-check-{input,label}
…
Its custom properties, for your own css: var(--bs-primary), var(--bs-body-font-family), …

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:

src/sandbox/wrapper.tsx
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>
  )
}
src/sandbox/components.ts
export { Button, Card, CardContent, Slider, Stack, TextField, Typography, Menu, MenuItem } from '@mui/material'
export { PriceTag } from '../components/price_tag' // your own styled() components
vite.config.ts
genuiSandboxKit({
  components: 'src/sandbox/components.ts',
  wrapper: 'src/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:

  1. a placeholder of initialHeight;
  2. 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);
  3. 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

No Vite runs in the Nest process anyway. When the Nest app and the frontend are separate projects, sandboxKit points at what the frontend's plugin writes (a complete setup: A separate SPA). When the frontend is not built with this plugin, the server still needs the docs. @dudousxd/nestjs-codegen's genuiSandboxKit() extension writes the same .genui/sandbox-kit.json on every codegen pass (one-off, --watch, and the module's watcher). It takes the same options as the plugin:

nestjs-codegen.config.ts
import { defineConfig, genuiSandboxKit } from '@dudousxd/nestjs-codegen';

export default defineConfig({
  contracts: { glob: 'src/**/*.controller.ts' },
  codegen: { outDir: 'src/generated' },
  extensions: [genuiSandboxKit({ components: 'web/components/ui', styles: 'web/styles/globals.css' })],
});

The same writer is exported for your own tooling: writeSandboxKitDocs({ root, output, components, styles, wrapper, stylesDocs }) from @dudousxd/nestjs-agent-core/genui/kit. It compiles the styles with Vite when it is installed (for Sass and PostCSS); without it, it reads plain CSS as it is. Without a bundle the descriptor carries docs only, so the browser still needs the kit served from somewhere (sandboxKit.kitUrl).

Drawing it in React

SandboxView (from @dudousxd/nestjs-agent-react/genui) reads GET /agent/config through the enclosing AgentProvider, and waits for it before deciding a JSX view has no kit. createSandboxRenderer({ config, theme }) overrides that. config is one of:

  • an object: { theme, kit?: { url, hash? }, css?: { url, hash? }, tailwind?: { url, css? } };
  • a url answering it;
  • false (theme only).

A JSX view on a page with no kit shows its summary instead.

Sizes

Theme injection costs nothing measurable. Everything else is inlined into every sandbox frame that uses it: fetched over the network once per page, but parsed once per frame.

  • Styles: whatever they compile to, plus inlined fonts (capped by inlineAssetBudget).
  • Tailwind: ≈280 KB.
  • The kit: ≈230 KB of React plus your components and their dependencies (a dozen MUI components with emotion and a theme add ≈180 KB).

Keep the kit to what a generated view needs.

Reference

ExportFrom
genuiSandboxKit(options)@dudousxd/nestjs-agent/viteThe Vite plugin: styles, stylesDocs, components, wrapper, plugins, tsconfig, typescript, inlineAssetLimit, inlineAssetBudget, descriptor (default .genui/sandbox-kit.json)
AgentGenuiModule.forRoot({ sandbox, sandboxKit })@dudousxd/nestjs-agent/genuisandbox: { theme, kit }; sandboxKit: { root, descriptor, manifest, kitUrl, production }
defineSandbox({ theme, kit })@dudousxd/nestjs-agent-core/genuikit also takes docs (or a function returning them)
createSandboxRenderer({ config, theme })@dudousxd/nestjs-agent-react/genuiconfig: an object, a url, or false; default the agent's /config
writeSandboxKitDocs, buildSandboxKitBundle, buildSandboxKitStyles, discoverStyleEntries, sandboxKitDiscovery, resolveSandboxServer@dudousxd/nestjs-agent-core/genui/kitThe Node half, for your own tooling
genuiSandboxKit() (codegen extension)@dudousxd/nestjs-codegenWrites the descriptor on every codegen pass
extractCssVocabulary, tailwindTokensFromCss, tailwindV3ConfigToCss, inlineCssUrls, transpileJsx, tailwindThemeCss, collectHostThemeVars@dudousxd/nestjs-agent-core/genuiThe isomorphic pieces

On this page