---
title: Architecture
description: 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.

<img
  src="/images/openui-lang/architecture.png"
  alt="Architecture diagram showing two phases: GENERATE (one-time) and EXECUTE (ongoing, no LLM)"
/>

### 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 code, a compact declarative format that describes the UI layout, data sources, and interactions.

### Execute

The Renderer parses the generated code. When it encounters a `Query("list_tickets")`, the runtime calls your tool directly, no LLM roundtrip. When the user clicks a button that triggers `@Run(createResult)`, the runtime executes the mutation against your tool. When a `$variable` changes from a dropdown, all dependent queries re-fetch automatically.

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](https://modelcontextprotocol.io/docs/getting-started/intro) or function map), the LLM can wire it into a UI

Try it live: [Open the GitHub Demo](/demo/github)

## Iterate and refine

The LLM doesn't have to get it right the first time. With [incremental editing](/docs/openui-lang/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(["Open","Closed"], ← new
tickets = Query(...)                   [@Count(@Filter(..., "open")),
tbl = Table([...])                      @Count(@Filter(..., "closed"))
                                       ], "donut")
  20 lines, ~400 tokens               3 lines, ~60 tokens (85% fewer)
```

<video
  src="/videos/openui-lang/incremental-editing.mp4"
  autoPlay
  loop
  muted
  playsInline
  className="w-full rounded-lg"
/>

## 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:

```tsx
<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](https://modelcontextprotocol.io/docs/getting-started/intro) client** for server-side tools:

```tsx
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 are simplified for demonstration. In production, you'll need to handle authentication, error boundaries, and connection lifecycle.

The [GitHub Demo](/demo/github) uses a function map where tools run entirely client-side, calling the GitHub API directly from the browser. The [Dashboard example](https://github.com/thesysdev/openui/tree/main/examples/openui-dashboard) uses MCP with server-side tools.

## A concrete example

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

### 1. Define your tools

```ts
// tools.ts
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

```ts
// route.ts
import { generatePrompt } from "@openuidev/lang-core";
import componentSpec from "./generated/component-spec.json";

const systemPrompt = generatePrompt({
  ...componentSpec,
  tools,
  toolCalls: true,
  bindings: true,
});

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

### 3. Render the response

```tsx
// page.tsx
<Renderer
  library={library}
  response={streamedText}
  isStreaming={isStreaming}
  toolProvider={{
    list_tickets: async () => db.query("SELECT * FROM tickets"),
    create_ticket: async (args) => db.query("INSERT INTO tickets ...", args),
  }}
/>
```

### What the LLM generates

```text
$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)]))
form = Form("create", submitBtn, [
  FormControl("Title", Input("title", $title)),
  FormControl("Priority", Select("priority", $priority, [
    SelectItem("low", "Low"), SelectItem("medium", "Medium"), SelectItem("high", "High")
  ]))
])
tbl = Table([Col("Title", tickets.rows.title), Col("Priority", tickets.rows.priority)])
root = Stack([CardHeader("Ticket Tracker"), form, tbl])
```

<video
  src="/videos/openui-lang/ticket-tracker.mp4"
  autoPlay
  loop
  muted
  playsInline
  className="w-full rounded-lg"
/>

### What happens at runtime

1. `Query("list_tickets")` → runtime calls your tool → table fills with data
2. User types a title, picks a priority, clicks "Create"
3. `@Run(createResult)` → runtime calls `create_ticket` directly
4. `@Run(tickets)` → runtime re-fetches `list_tickets` → table updates
5. `@Reset($title, $priority)` → form clears

All without the LLM.
