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.

Primitive

≈ 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> or aria-label), keep it keyboard operable, and put results that change in an aria-live region. Give the call a description for screen readers.
  • Size to content. Don't use vh units or full-height layouts; the frame grows with the content (up to 1200px, then it scrolls). Pass height if 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.

VariableUse
--foregroundText, strokes
--muted-foregroundSecondary text
--backgroundThe page; good for a selected segment
--cardThe frame's own background, so the app blends into the card
--mutedFills: inputs, tracks, chips (translucent)
--borderHairlines (translucent)
--primary, --primary-foregroundPrimary button
--brandSlider fills and thumbs only
--ringFocus outline
--radiusBase corner radius

Base styles. All are zero-specificity (:where()), so any rule the app writes wins:

  • system-ui 14px, body on --card with 4px padding (room for focus rings).
  • button: a pill on --primary. button.secondary is a muted pill; button.icon a 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 .slider row that holds the <input type="range"> and its <output class="value"> (small grey mono number) on the right, inside a .field wrapper. 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. Without allow-same-origin the frame gets an opaque origin: no access to the host page, its cookies, storage or DOM. Without allow-forms, allow-popups, allow-modals or allow-top-navigation it can't submit forms, open windows, show alert() 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, no fetch/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 load event 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.

DirectionMessageHost 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 JSON

If 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-origin together with allow-scripts: that lets the app remove its own sandbox.
  • Treat value as 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. srcdoc frames inherit the host's policy. If the host uses a nonce- or hash-based script-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

app/api/chat/route.ts
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 }),
  }),
}
components/message.tsx
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

PropTypeNotes
titlestringCard title and frame title. Up to 80 characters
htmlstringBody markup with inline <style> and <script>. Up to 20,000 characters; longer shows "This app is too large to run"
heightnumber?Expected height in px, 120–800. Default 240. Replaced by the app's own size once it reports
descriptionstring?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.

Source

On this page