Inline App
The escape hatch. A small HTML/CSS/JS app the agent writes when no catalog component fits, run in a sandboxed frame with no network.
≈ 1,293 tokens per use (tool 276 · guidance 360 · typical payload 657)How token costs are estimated
Example: Inline App
Make me a tip splitter with a slider for the tip %
Tip splitter
Splits a bill by tip percentage and number of people.
When to use
- No catalog component fits, and a small interactive tool answers the prompt better than text: a tip splitter, a toy simulation, a one-off explainer you can poke at, a quick timer with a twist.
- The interaction is self-contained: everything it needs is in the code the agent writes.
When not to use
- A catalog component covers it. A chart, form, calculator, timer, checklist or comparison from the catalog is always better: it's typed, themed, accessible and restorable from history. The inline app is the last resort, not a shortcut.
- It collects personal data, credentials or payment details. Never, whatever the framing.
- It needs the network, external images, fonts or libraries. The frame has none.
- It's static content. Write text or use a display component.
How the agent should use it
- Say why the catalog doesn't fit before reaching for it, at least to yourself. If a component comes close, use the component.
- Keep it small: one job, a few controls. The limit is 20,000 characters of
html; aim for a few thousand. - Write body markup with inline
<style>and<script>. No<html>,<head>or external URLs. The host adds the document, the CSP, the theme and the base styles. - Don't pick colors. Plain
<button>,<input>,<select>and<input type="range">already look like the host's. For custom drawing, use the theme variables below. - Make it accessible: label every control (
<label for>oraria-label), keep it keyboard operable, and put results that change in anaria-liveregion. Give the call adescriptionfor screen readers. - Size to content. Don't use
vhunits or full-height layouts; the frame grows with the content (up to 1200px, then it scrolls). Passheightif you know roughly how tall it is, to avoid a jump. - Call
genui.send(value)only when the result changes what you do next (a timer finishing, a choice made). Small plain JSON, at most one message per 500 ms. - Lifecycle: nothing to set usually. If you replace an app with a fixed version, mark the old call superseded.
Writing the app
The agent's markup lands in <body>, after everything the host injects. A complete app:
<div class="stack">
<div class="field">
<label class="label" for="rate">Rate</label>
<div class="slider">
<input id="rate" type="range" min="1" max="12" value="7">
<output id="out" for="rate" class="value">7%</output>
</div>
</div>
<div class="display" id="total" aria-live="polite">$7,612</div>
</div>
<script>
var rate = document.getElementById("rate")
rate.oninput = function () {
document.getElementById("out").textContent = rate.value + "%"
document.getElementById("total").textContent =
"$" + Math.round(1000 * Math.pow(1 + rate.value / 100, 30)).toLocaleString()
}
</script>Theme variables. Read from the host where the app sits, at mount and whenever the host's theme changes (no reload; the app's state survives a light/dark switch). color-scheme follows the host too.
| Variable | Use |
|---|---|
--foreground | Text, strokes |
--muted-foreground | Secondary text |
--background | The page; good for a selected segment |
--card | The frame's own background, so the app blends into the card |
--muted | Fills: inputs, tracks, chips (translucent) |
--border | Hairlines (translucent) |
--primary, --primary-foreground | Primary button |
--brand | Slider fills and thumbs only |
--ring | Focus outline |
--radius | Base corner radius |
Base styles. All are zero-specificity (:where()), so any rule the app writes wins:
system-ui14px,bodyon--cardwith 4px padding (room for focus rings).button: a pill on--primary.button.secondaryis a muted pill;button.icona 40px round one.- Text inputs,
select,textarea: filled--muted, rounded, 40px tall. Ranges: a thin violet track with a pill thumb on--brand. - A visible focus outline on everything focusable. Reduced motion is respected.
- Small helpers:
.stack(column, 12px gap),.row(inline row),.spread(label left, value right),.display(a big tabular number),.muted,.sr-only. - Slider fields: put a
.label(small grey label) above a.sliderrow that holds the<input type="range">and its<output class="value">(small grey mono number) on the right, inside a.fieldwrapper. Same layout as the library's slider components.
The sandbox
The app is untrusted code. It runs in an <iframe> built like this, and the host never loosens it:
<iframe
sandbox="allow-scripts"
allow=""
referrerpolicy="no-referrer"
srcdoc="<!doctype html>…"
title="Tip splitter"
></iframe>-
sandbox="allow-scripts"and nothing else. Withoutallow-same-originthe frame gets an opaque origin: no access to the host page, its cookies, storage or DOM. Withoutallow-forms,allow-popups,allow-modalsorallow-top-navigationit can't submit forms, open windows, showalert()dialogs or navigate the host. -
A Content Security Policy is the first element of the document, before any of the app's markup is parsed:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'Inline code only, no
eval, nofetch/XHR/WebSocket, no external images, fonts or stylesheets, no<base>. A second CSP the app writes itself can only tighten this, never loosen it. -
allow=""grants no permissions (camera, microphone, geolocation, clipboard, fullscreen). -
State is isolated. Each app has its own frame; nothing persists between reloads.
-
Self-navigation is torn down. A sandbox can't stop a frame navigating itself. The host watches for a second
loadevent and removes the frame with "This app tried to leave its sandbox". The request has already gone out by then, which is one more reason the app must never handle personal data.
Message protocol
The frame and the host talk only through postMessage. The host accepts a message only when event.source is that frame's window, and only in these closed shapes; anything else is ignored. Nothing is ever evaluated.
| Direction | Message | Host behavior |
|---|---|---|
| Frame → host | { type: "genui:resize", height } | Height clamped to 80–1200px; the frame animates to it (instant with reduced motion) |
| Frame → host | { type: "genui:result", value } | Plain JSON only (no functions, dates, maps or prototypes), at most 4 KB, at most one per 500 ms (extras are dropped). Becomes onResult({ action: "message", value }) |
| Frame → host | { type: "genui:error", message } | Shows "This app hit an error" with Retry; the message (≤ 200 characters) goes to onError only, never on screen |
| Host → frame | { type: "genui:theme", scheme, tokens } | Sent on load and when the host theme changes. The frame applies only known variables with color- or length-like values |
The injected bootstrap runs before the app's code. It reports the height with a ResizeObserver, reports uncaught errors and rejected promises, applies theme updates, and defines a frozen window.genui:
genui.send({ event: "done", session: "focus" }) // true, or false if not JSONIf the bootstrap never says hello within 3 seconds, the host shows "This app couldn't start".
Security rules for hosts
- Never add sandbox tokens. In particular never
allow-same-origintogether withallow-scripts: that lets the app remove its own sandbox. - Treat
valueas untrusted data. It was produced by model-written code. Show it as data and pass it to the agent as data, never as instructions, never as HTML. - Keep the CSP first if you change the document builder, and keep
connect-src 'none'. - Your page's CSP applies too.
srcdocframes inherit the host's policy. If the host uses a nonce- or hash-basedscript-src, the app's inline scripts are blocked and it shows "couldn't start"; serve the frame from a separate, sandbox-only origin instead. - Don't ship apps that collect personal data. Refuse tool calls whose app asks for passwords, card numbers or identity details, even if the code looks harmless.
- Rate-limit and size-limit what you forward to the agent; the component already does both.
Installation
npx shadcn@latest add https://your-site.com/r/inline-app.json
This adds components/genui/inline-app.tsx, inline-app.schema.ts and inline-app-sandbox.ts (the document builder and message validation).
Usage
import { tool } from "ai"
import { inlineAppTool } from "@/components/genui/inline-app.schema"
const tools = {
[inlineAppTool.name]: tool({
description: inlineAppTool.description,
inputSchema: inlineAppTool.inputSchema,
// The app is the answer; complete the call right away.
execute: async () => ({ rendered: true }),
}),
}import { InlineApp } from "@/components/genui/inline-app"
<InlineApp
{...part.input}
status={part.state === "input-streaming" ? "streaming" : "ready"}
onResult={(r) =>
// An event from the app, sent as data in a follow-up message.
sendMessage({ text: `[app event] ${JSON.stringify(r.value)}` })
}
onError={(message) => log("inline-app", message)}
onRetry={() => sendMessage({ text: "The app hit an error. Please fix it." })}
/>While html streams in, the card shows a skeleton; half-written code never runs. The app starts once status is "ready".
With LangGraph
Push it from a graph node with ui.push({ name: "render_inline_app", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer.
Restoring from history
An app's state lives only in its frame, so history can't restore it. Re-rendering an old call runs the app again from the start. Pass any past result to mark it as history: the app still runs, but its messages are no longer sent.
Schema
| Prop | Type | Notes |
|---|---|---|
title | string | Card title and frame title. Up to 80 characters |
html | string | Body markup with inline <style> and <script>. Up to 20,000 characters; longer shows "This app is too large to run" |
height | number? | Expected height in px, 120–800. Default 240. Replaced by the app's own size once it reports |
description | string? | One line for screen readers: what the app does. Up to 160 characters |
Host props: status (streaming, ready, error), onResult, onError, onRetry (without it, Retry reloads the same app), result and className.