OpenUI

Architecture

Build dashboards, CRUD interfaces, and monitoring tools. The LLM generates the UI once, then your app runs independently.

The problem today

In most AI-powered applications, when a user interacts with a generated UI (filtering data, submitting a form, refreshing a view), the request goes back through the LLM. The model re-processes the context, calls tools, and regenerates the response. Every click costs tokens. Every interaction adds latency.

How OpenUI changes this

OpenUI separates generation from execution. The LLM generates the interface once. After that, the UI runs on its own: fetching data, handling state, and responding to user actions without any LLM involvement.

Generate once, then executeGenerate runs once per request: the user's request goes to your backend, which sends the system prompt and tool specs to the model, and the model writes an OpenUI Lang program. Execute runs after generation, without the model: the Renderer parses the program and renders your components. When the user clicks or changes a $variable, the runtime calls your tools through the toolProvider and sends the rows back to the components.GenerateOnce per requestRequestThe user asksfor a UIYour backendSystem promptand tool specsModelWrites theprogramOpenUI LangLayout, queries,and actionsProgramExecuteEvery click, no modelRendererParses theprogramComponentsYour designsystemRuntimeRuns queriesand actionsYour toolsFunction mapor MCPRendersClicksDataCallRows

Generate

The user describes what they want. Your backend sends the request to an LLM along with a system prompt that includes your component library and tool descriptions. The LLM responds with OpenUI Lang, a compact format that describes the UI layout, data sources, and interactions.

Execute

The Renderer parses the generated code in the browser. When it reaches Query("list_tickets"), the runtime calls your tool through the toolProvider, with no LLM call. When the user clicks a button that runs @Run(createResult), the runtime runs the mutation with your tool. When a dropdown changes a $variable, the runtime fetches every query that uses it again.

The LLM generated the wiring. The runtime executes it.

What this enables

  • Reactive dashboards with date range filters, auto-refresh, and live KPIs computed from query results
  • CRUD interfaces with create forms, edit modals, tables with search and sort
  • Monitoring tools with periodic refresh, server health metrics, and error rate tracking
  • Any tool-connected UI. If you can expose it as a tool (via MCP or function map), the LLM can wire it into a UI

Try it live: Open the GitHub Demo

Iterate and refine

The LLM doesn't have to get it right the first time. With incremental editing, the user says "add a pie chart" and the LLM outputs only the 2-3 changed statements and the parser merges them into the existing code. Existing queries, state, and bindings stay intact.

Turn 1:                              Turn 2 (patch only):

root = Stack([header, tbl])          root = Stack([header, chart, tbl])  ← updated
header = CardHeader("Tickets")       chart = PieChart(...)               ← new
tickets = Query(...)
tbl = Table([...])

For example, a full program of 20 lines might take about 400 tokens. A patch of 2 or 3 lines might take about 60.

Connecting tools

The Renderer accepts a toolProvider prop that tells the runtime how to call your tools. Two options:

Function map, a plain object of async functions:

<Renderer
  toolProvider={{
    list_tickets: async (args) => fetch("/api/tickets").then((r) => r.json()),
    create_ticket: async (args) =>
      fetch("/api/tickets", { method: "POST", body: JSON.stringify(args) }).then((r) => r.json()),
  }}
  response={code}
  library={library}
/>

MCP client for tools that run on an MCP server:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "my-app", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("/api/mcp")));
<Renderer toolProvider={client} response={code} library={library} />;

These examples leave out authentication, error handling, and connection lifecycle. Production code needs them.

The toolProvider runs in the browser, next to the Renderer. The GitHub Demo uses a function map that calls the GitHub API from the browser. To keep data access on your server, point the functions at your API routes or connect an MCP client to your server.

A concrete example

Here's the full flow for a ticket tracker: define tools, generate the prompt, render the output.

1. Define your tools

// tools.ts
import type { ToolSpec } from "@openuidev/lang-core";

export const tools: ToolSpec[] = [
  {
    name: "list_tickets",
    description: "List all tickets",
    inputSchema: { type: "object", properties: {} },
    outputSchema: {
      type: "object",
      properties: {
        rows: {
          type: "array",
          items: {
            type: "object",
            properties: { title: { type: "string" }, priority: { type: "string" } },
          },
        },
      },
    },
  },
  {
    name: "create_ticket",
    description: "Create a new ticket",
    inputSchema: {
      type: "object",
      properties: {
        title: { type: "string" },
        priority: { type: "string" },
      },
    },
    outputSchema: { type: "object", properties: { success: { type: "boolean" } } },
  },
];

2. Generate the prompt and call the LLM

The prompt must list your tools, so your backend builds it with generateSystemPrompt and calls the provider. Gateway builds its own prompt from generateSystemPrompt({ cloud: true, library }) and accepts only preamble, examples, and additionalRules. It drops every other option, including tools. System Prompts lists them all.

// route.ts
import OpenAI from "openai";
import { generateSystemPrompt, type LibrarySpec } from "@openuidev/lang-core";
import componentSpec from "./generated/component-spec.json";
import { tools } from "./tools";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const systemPrompt = generateSystemPrompt({
  library: componentSpec as LibrarySpec,
  promptOptions: { tools, toolCalls: true, bindings: true },
});

const completion = await client.chat.completions.create({
  model: "gpt-5",
  stream: true,
  messages: [
    { role: "system", content: systemPrompt },
    { role: "user", content: "Build a ticket tracker with a create form and table" },
  ],
});

3. Render the response

The Renderer runs in the browser, and so does the toolProvider. Each tool calls an API route that reads or writes your database on the server:

// page.tsx, a client component
<Renderer
  library={library}
  response={streamedText}
  isStreaming={isStreaming}
  toolProvider={{
    list_tickets: async () => fetch("/api/tickets").then((r) => r.json()),
    create_ticket: async (args) =>
      fetch("/api/tickets", { method: "POST", body: JSON.stringify(args) }).then((r) => r.json()),
  }}
/>

What the LLM generates

root = Stack([CardHeader("Ticket Tracker"), form, tbl])
$title = ""
$priority = "medium"
createResult = Mutation("create_ticket", {title: $title, priority: $priority})
tickets = Query("list_tickets", {}, {rows: []})
submitBtn = Button("Create", Action([@Run(createResult), @Run(tickets), @Reset($title, $priority)]), "primary")
form = Form("create", Buttons([submitBtn]), [
  FormControl("Title", Input("title", "Ticket title", "text", {required: true}, $title)),
  FormControl("Priority", Select("priority", [
    SelectItem("low", "Low"), SelectItem("medium", "Medium"), SelectItem("high", "High")
  ], null, null, $priority))
])
tbl = Table([Col("Title", tickets.rows.title), Col("Priority", tickets.rows.priority)])

What happens at runtime

  1. Query("list_tickets"): the runtime calls your tool and the table fills with data.
  2. The user types a title, picks a priority, and clicks "Create".
  3. @Run(createResult): the runtime calls create_ticket.
  4. @Run(tickets): the runtime fetches list_tickets again and the table updates.
  5. @Reset($title, $priority): the form clears.

The LLM is not called for any of these.

On this page